Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

10장. 변수의 범위

함수를 자유롭게 쓸 수 있게 됐다. 그러자 새로운 질문이 따라온다.

함수 안에서 만든 변수는 함수 밖에서도 보이나? if 블록 안에서 만든 변수는 그 블록을 벗어나면 어떻게 되나? 같은 이름의 변수를 안과 밖에 모두 두면 어느 쪽이 우선인가?

이 답을 묶어서 변수의 범위(scope) 라고 부른다.

이 장의 목표:

  • 변수의 범위라는 개념을 한 줄로 설명할 수 있다
  • 패키지 / 함수 / 블록 세 단계의 범위를 구분한다
  • 변수 가리기(shadowing) 가 무엇인지 안다
  • := 가 일으키는 흔한 함정을 피한다
  • 전역 변수를 함부로 쓰지 않는 이유를 안다

10.1 변수의 범위(scope)란

변수의 범위란 한 마디로 그 이름이 가리키는 변수가 보이는 영역 이다.

func main() {
    x := 10
    fmt.Println(x) // OK
}

fmt.Println(x) // 컴파일 에러: x 가 없음

xmain 함수 안에서만 존재한다. 함수가 끝나면 사라지고, 함수 바깥에서는 보이지 않는다.

Go 에서 범위는 중괄호 { } 단위로 결정된다. 중괄호로 둘러싸인 영역이 하나의 블록이 되고, 그 안에서 선언된 변수는 그 블록의 범위를 가진다.

이 단순한 규칙 위에 세 가지 단계가 쌓여 있다.


10.2 세 가지 범위

패키지 수준 (전역 변수)

함수 밖에 선언한 변수는 같은 패키지의 어떤 함수에서도 접근할 수 있다. 보통 “전역 변수” 라고 부른다.

package main

import "fmt"

var greeting = "Hello"

func sayHi() {
    fmt.Println(greeting)
}

func sayBye() {
    fmt.Println(greeting, "and bye")
}

func main() {
    sayHi()
    sayBye()
}

greetingsayHi, sayBye, main 어디서나 보인다. 같은 디렉터리의 다른 .go 파일에서도 같은 패키지라면 접근할 수 있다.

다른 패키지에서도 보이게 하려면 이름의 첫 글자를 대문자로 쓴다 (Greeting). 패키지 간 export 규칙은 20장에서 자세히 다룬다.

패키지 수준 변수는 := 로 선언할 수 없다. 반드시 var 또는 const 로 적는다.

// OK
var count = 0
const limit = 100

// 컴파일 에러
count := 0

함수 수준 (지역 변수)

함수 안에서 선언한 변수는 그 함수 안에서만 살아 있다.

func work() {
    msg := "내부 메시지"
    fmt.Println(msg)
}

func main() {
    work()
    fmt.Println(msg) // 컴파일 에러: msg 가 없음
}

함수가 호출될 때마다 새로운 지역 변수들이 만들어지고, 함수가 끝나면 사라진다.

블록 수준 ({ } 안)

함수 안이라도 더 좁은 범위가 있다. if, for, switch 의 본문도 각자 하나의 블록이고, 그 안에서 선언한 변수는 그 블록 안에서만 보인다.

func main() {
    if x := 10; x > 0 {
        fmt.Println(x) // OK
    }
    fmt.Println(x) // 컴파일 에러
}

if 의 조건 앞에 선언한 xif ~ else 블록 전체에서만 살아 있다. 바깥에서는 같은 이름의 변수가 따로 있어야 한다.

for 도 마찬가지다.

for i := 0; i < 3; i++ {
    fmt.Println(i)
}
fmt.Println(i) // 컴파일 에러

루프 변수 i 는 for 블록 안에서만 보인다.

심지어 그냥 중괄호를 열기만 해도 새 블록이 된다.

func main() {
    {
        msg := "안쪽 블록"
        fmt.Println(msg)
    }
    fmt.Println(msg) // 컴파일 에러
}

이 패턴은 자주 쓰진 않지만, “변수 범위를 일부러 좁히고 싶을 때” 쓸 수 있다.

세 단계 한눈에

범위선언 위치보이는 영역
패키지 수준함수 밖같은 패키지 전체
함수 수준함수 본문 시작부그 함수 안
블록 수준{ }그 블록 안

10.3 함수 매개변수의 범위

함수 매개변수도 결국 변수다. 범위는 함수 본문 전체 다.

func greet(name string) {
    fmt.Println("Hello,", name)
}

namegreet 안에서만 보인다. greet 가 끝나면 사라진다.

매개변수는 복사된다

Go 의 매개변수는 기본적으로 값 복사 다. 함수 안에서 매개변수를 바꿔도 호출한 쪽의 원본은 영향을 받지 않는다.

func bump(n int) {
    n = n + 1
    fmt.Println("안:", n)
}

func main() {
    x := 10
    bump(x)
    fmt.Println("밖:", x)
}

실행 결과:

안: 11
밖: 10

bump 안의 nx 의 복사본이다. 복사본을 고친 것이지 x 자체를 고친 것은 아니다.

함수 안에서 호출자의 변수를 진짜로 바꾸고 싶다면 포인터 를 넘겨야 한다. 포인터는 14장에서 다룬다. 지금은 “매개변수는 복사된다” 만 기억해 두자.


10.4 변수 가리기 (shadowing)

같은 이름의 변수를 바깥 블록과 안쪽 블록 에 동시에 두면 어떻게 될까?

func main() {
    x := 10
    fmt.Println("바깥:", x)

    {
        x := 99
        fmt.Println("안쪽:", x)
    }

    fmt.Println("다시 바깥:", x)
}

실행 결과:

바깥: 10
안쪽: 99
다시 바깥: 10

안쪽 블록의 x 는 새로 만들어진 별개의 변수다. 바깥의 x 는 그대로 10 으로 살아 있고, 안쪽 블록이 끝나는 순간 안쪽 x 는 사라진다.

이렇게 안쪽 변수가 같은 이름의 바깥 변수를 가려 버리는 것을 변수 가리기(shadowing) 라고 부른다.

if / for 안에서 의도치 않게

문제는 의도하지 않은 shadowing 이다.

func main() {
    err := setup()
    if err != nil {
        return
    }

    if v, err := compute(); err == nil { // err 가 새로 만들어짐
        fmt.Println(v)
    }

    fmt.Println(err) // 어떤 err? 바깥의 err, 즉 setup 의 결과
}

if v, err := compute(); ...err 는 조건의 짧은 명령문이라서 새로운 변수 다. 바깥의 err 와는 다른 변수다.

코드를 빨리 읽으면 마지막 errcompute() 의 결과처럼 보이지만, 실제로는 setup() 의 결과다. 이런 혼동이 실제로 자주 일어난다.


10.5 := 의 함정

shadowing 의 단골 원인이 := 다. 원리를 짧게 정리해 둔다.

  • var x int 는 항상 새 변수를 만든다
  • x = 10 은 이미 있는 x 에 값을 넣는다
  • x := 10그 블록 안에 같은 이름의 변수가 없으면 새로 만들고, 있으면 그대로 쓴다… 가 아니다. 이미 그 블록 안에 같은 이름이 있으면 에러 다.

핵심은 “그 블록 안에” 라는 부분이다. 바깥 블록에 같은 이름이 있어도, 새로운 안쪽 블록에서 := 를 쓰면 새 변수가 만들어진다.

func main() {
    x := 10
    {
        x := 20  // 안쪽 블록의 새 변수
        fmt.Println(x) // 20
    }
    fmt.Println(x) // 10
}

다중 반환에서의 미묘함

가장 헷갈리는 케이스가 다중 반환의 := 다.

func main() {
    n, err := strconv.Atoi("10")
    if err != nil { /* ... */ }

    n, err := strconv.Atoi("20") // ?
}

위 코드는 컴파일 에러다. n, err 둘 다 이미 같은 블록에 있기 때문이다. 이 경우엔 = 를 써야 한다.

n, err = strconv.Atoi("20")

그런데 다중 반환의 := 는 특이한 규칙이 하나 있다.

왼쪽 이름들 중에 하나라도 새 변수 면, 같은 블록 안이라도 := 가 허용된다. 이미 있는 이름은 단순 대입처럼 동작한다.

예:

n, err := strconv.Atoi("10")
m, err := strconv.Atoi("20")  // OK: m 은 새 변수, err 는 기존 변수에 대입

문제는 이 규칙이 블록이 다를 때 와 어우러지면 조용한 shadowing 을 만든다는 점이다.

err := setup()

if cond {
    n, err := compute()  // 여기서 err 는 새 변수 (블록이 다름)
    _ = n
    if err != nil {
        return
    }
}

// 여기 err 는 setup() 의 결과
// compute() 의 err 는 if 블록을 벗어나며 버려졌다

위 코드는 컴파일도 잘 되고 실행도 잘 된다. 하지만 compute() 의 에러는 처리된 적이 없다. 조용히 묻혔다.

어떻게 피하나

  • 안쪽 블록에서도 같은 변수를 쓰려면 = 를 의식적으로 쓴다.
  • 변수 이름을 일부러 다르게 짓는다 (err, errComp).
  • 도구의 도움을 받는다.

마지막 항목이 중요하다. Go 표준 도구인 go vet 에는 shadowing 을 잡아 주는 분석기가 있었다. 요즘은 별도 도구로 분리되어 있지만, 린터 묶음(golangci-lint) 에 포함된 shadow 분석기로 흔히 검사한다.

golangci-lint run --enable=shadow

이런 도구를 처음부터 켜 두는 습관을 추천한다. 사람이 눈으로 잡기 가장 어려운 종류의 버그다.


10.6 전역 변수를 자제해야 하는 이유

패키지 수준 변수는 편리해 보인다. 어디서든 접근할 수 있고, 인자로 매번 넘기지 않아도 된다.

하지만 코드가 조금만 커지면 빠르게 문제가 된다.

추적이 어렵다

전역 변수의 값은 패키지 어디에서나 바뀔 수 있다. 버그가 났을 때

“이 값이 왜 이렇게 됐지?”

를 알려면 패키지의 모든 함수를 들춰 봐야 한다. 함수 인자로 들어오는 값은 호출 지점만 보면 되지만, 전역 변수는 그렇지 않다.

테스트가 어렵다

테스트는 보통 “이 함수에 이 입력을 주면 이 결과가 나온다” 형태로 짠다. 함수가 전역 변수를 참조하고 있으면 입력만으로 결과가 결정되지 않는다.

  • 테스트 실행 순서에 따라 결과가 달라진다
  • 다른 테스트가 전역 상태를 오염시키면 같이 깨진다
  • 테스트마다 전역 변수를 초기화/복원하는 코드가 늘어난다

테스트는 32장에서 본격적으로 다룬다. 지금은 “전역 변수가 늘면 테스트가 어려워진다” 정도만 기억하자.

동시성에서 위험하다

여러 고루틴이 동시에 전역 변수를 읽고 쓰면 경쟁 조건(race condition)이 생길 수 있다. 값이 중간에 깨지거나, 한쪽의 변경이 다른 쪽에 안 보인다.

이 문제는 매우 복잡하고 디버깅도 어렵다. 9부(22~25장) 에서 동시성을 다룰 때 자세히 본다.

그럼 언제 써도 되나

전부 금지하라는 뜻은 아니다. 다음 같은 경우는 자연스럽다.

  • const 로 선언한 상수 (값이 바뀌지 않으니 안전)
  • 패키지 전체에 한 번만 만들어지는 객체
    • 예: 로거, 설정, DB 커넥션 풀
    • 보통 변수가 아니라 함수로 노출한다
  • 매우 작은 유틸 패키지의 내부 캐시 등

기준은 단순하다.

자주 바뀌는 값은 전역 변수에 두지 않는다.

자주 바뀐다면 함수 인자로 넘기거나 구조체에 담아 다닌다 (13장).


10.7 정리

이 장에서 살펴본 내용:

  • 변수의 범위는 그 변수가 보이는 영역이다
  • Go 는 중괄호 { } 단위로 범위가 갈린다
  • 세 단계: 패키지 / 함수 / 블록
  • if, for, switch 의 본문은 각자 하나의 블록
  • 함수 매개변수는 복사돼서 함수 내부에서만 산다
  • 같은 이름을 안쪽에서 다시 선언하면 변수 가리기가 일어난다
  • 특히 := 다중 반환과 블록의 조합은 조용한 shadowing 의 원인
  • 린터 (golangci-lintshadow) 로 미리 잡는다
  • 전역 변수는 추적, 테스트, 동시성에서 모두 비싸다. 꼭 필요할 때만 쓴다

여기까지가 “기본 흐름과 함수 모양” 이다. 이제 데이터를 좀 더 본격적으로 묶어 다룰 차례다.

다음 장(11장) 부터는 여러 데이터를 묶는 자료구조 다. 배열과 슬라이스, 맵, 구조체로 이어진다. 지금까지 배운 for range, 함수, 범위 개념이 하나하나 다시 등장한다.